// JSLitmus.js
//
// Copyright (c) 2010, Robert Kieffer, http://broofa.com
// Available under MIT license (http://en.wikipedia.org/wiki/MIT_License)

(function() {
	// Private methods and state

	// Get platform info but don't go crazy trying to recognize everything
	// that's out there.  This is just for the major platforms and OSes.
	var platform = 'unknown platform', ua = navigator.userAgent;

	// Detect OS
	var oses = ['Windows','iPhone OS','(Intel |PPC )?Mac OS X','Linux'].join('|');
	var pOS = new RegExp('((' + oses + ') [^ \);]*)').test(ua) ? RegExp.$1 : null;
	if (!pOS) pOS = new RegExp('((' + oses + ')[^ \);]*)').test(ua) ? RegExp.$1 : null;

	// Detect browser
	var pName = /(Chrome|MSIE|Safari|Opera|Firefox)/.test(ua) ? RegExp.$1 : null;

	// Detect version
	var vre = new RegExp('(Version|' + pName + ')[ \/]([^ ;]*)');
	var pVersion = (pName && vre.test(ua)) ? RegExp.$2 : null;
	var platform = (pOS && pName && pVersion) ? pName + ' '  + pVersion + ' on ' + pOS : 'unknown platform';

	/**
	* A smattering of methods that are needed to implement the JSLitmus testbed.
	*/
	var jsl = {
	  /**
	  * Enhanced version of escape()
	  */
	  escape: function(s) {
		s = s.replace(/,/g, '\\,');
		s = escape(s);
		s = s.replace(/\+/g, '%2b');
		s = s.replace(/ /g, '+');
		return s;
	  },

	  /**
	  * Get an element by ID.
	  */
	  $: function(id) {
		return document.getElementById(id);
	  },

	  /**
	  * Null function
	  */
	  F: function() {},

	  /**
	  * Set the status shown in the UI
	  */
	  status: function(msg) {
		var el = jsl.$('jsl_status');
		if (el) el.innerHTML = msg || '';
	  },

	  /**
	  * Convert a number to an abbreviated string like, "15K" or "10M"
	  */
	  toLabel: function(n) {
		if (n == Infinity) {
		  return 'Infinity';
		} else if (n > 1e9) {
		  n = Math.round(n/1e8);
		  return n/10 + 'B';
		} else if (n > 1e6) {
		  n = Math.round(n/1e5);
		  return n/10 + 'M';
		} else if (n > 1e3) {
		  n = Math.round(n/1e2);
		  return n/10 + 'K';
		}
		return n;
	  },

	  /**
	  * Copy properties from src to dst
	  */
	  extend: function(dst, src) {
		for (var k in src) dst[k] = src[k]; return dst;
	  },

	  /**
	  * Like Array.join(), but for the key-value pairs in an object
	  */
	  join: function(o, delimit1, delimit2) {
		if (o.join) return o.join(delimit1);  // If it's an array
		var pairs = [];
		for (var k in o) pairs.push(k + delimit1 + o[k]);
		return pairs.join(delimit2);
	  },

	  /**
	  * Array#indexOf isn't supported in IE, so we use this as a cross-browser solution
	  */
	  indexOf: function(arr, o) {
		if (arr.indexOf) return arr.indexOf(o);
		for (var i = 0; i < this.length; i++) if (arr[i] === o) return i;
		return -1;
	  }
	};

	/**
	* Test manages a single test (created with
	* JSLitmus.test())
	*
	* @private
	*/
	var Test = function (name, f) {
	  if (!f) throw new Error('Undefined test function');
	  if (!/function[^\(]*\(([^,\)]*)/.test(f.toString())) {
		throw new Error('"' + name + '" test: Test is not a valid Function object');
	  }
	  this.loopArg = RegExp.$1;
	  this.name = name;
	  this.f = f;
	};

	jsl.extend(Test, /** @lends Test */ {
	  /** Calibration tests for establishing iteration loop overhead */
	  CALIBRATIONS: [
		new Test('calibrating loop', function(count) {while (count--);}),
		new Test('calibrating function', jsl.F)
	  ],

	  /**
	  * Run calibration tests.  Returns true if calibrations are not yet
	  * complete (in which case calling code should run the tests yet again).
	  * onCalibrated - Callback to invoke when calibrations have finished
	  */
	  calibrate: function(onCalibrated) {
		for (var i = 0; i < Test.CALIBRATIONS.length; i++) {
		  var cal = Test.CALIBRATIONS[i];
		  if (cal.running) return true;
		  if (!cal.count) {
			cal.isCalibration = true;
			cal.onStop = onCalibrated;
			//cal.MIN_TIME = .1; // Do calibrations quickly
			cal.run(2e4);
			return true;
		  }
		}
		return false;
	  }
	});

	jsl.extend(Test.prototype, {/** @lends Test.prototype */
	  /** Initial number of iterations */
	  INIT_COUNT: 10,
	  /** Max iterations allowed (i.e. used to detect bad looping functions) */
	  MAX_COUNT: 1e9,
	  /** Minimum time a test should take to get valid results (secs) */
	  MIN_TIME: .5,

	  /** Callback invoked when test state changes */
	  onChange: jsl.F,

	  /** Callback invoked when test is finished */
	  onStop: jsl.F,

	  /**
	   * Reset test state
	   */
	  reset: function() {
		delete this.count;
		delete this.time;
		delete this.running;
		delete this.error;
	  },

	  /**
	  * Run the test (in a timeout). We use a timeout to make sure the browser
	  * has a chance to finish rendering any UI changes we've made, like
	  * updating the status message.
	  */
	  run: function(count) {
		count = count || this.INIT_COUNT;
		jsl.status(this.name + ' x ' + count);
		this.running = true;
		var me = this;
		setTimeout(function() {me._run(count);}, 200);
	  },

	  /**
	   * The nuts and bolts code that actually runs a test
	   */
	  _run: function(count) {
		var me = this;

		// Make sure calibration tests have run
		if (!me.isCalibration && Test.calibrate(function() {me.run(count);})) return;
		this.error = null;

		try {
		  var start, f = this.f, now, i = count;

		  // Start the timer
		  start = new Date();

		  // Now for the money shot.  If this is a looping function ...
		  if (this.loopArg) {
			// ... let it do the iteration itself
			f(count);
		  } else {
			// ... otherwise do the iteration for it
			while (i--) f();
		  }

		  // Get time test took (in secs)
		  this.time = Math.max(1,new Date() - start)/1000;

		  // Store iteration count and per-operation time taken
		  this.count = count;
		  this.period = this.time/count;

		  // Do we need to do another run?
		  this.running = this.time <= this.MIN_TIME;

		  // ... if so, compute how many times we should iterate
		  if (this.running) {
			// Bump the count to the nearest power of 2
			var x = this.MIN_TIME/this.time;
			var pow = Math.pow(2, Math.max(1, Math.ceil(Math.log(x)/Math.log(2))));
			count *= pow;
			if (count > this.MAX_COUNT) {
			  throw new Error('Max count exceeded.  If this test uses a looping function, make sure the iteration loop is working properly.');
			}
		  }
		} catch (e) {
		  // Exceptions are caught and displayed in the test UI
		  this.reset();
		  this.error = e;
		}

		// Figure out what to do next
		if (this.running) {
		  me.run(count);
		} else {
		  jsl.status('');
		  me.onStop(me);
		}

		// Finish up
		this.onChange(this);
	  },

	  /**
	  * Get the number of operations per second for this test.
	  *
	  * @param normalize if true, iteration loop overhead taken into account
	  */
	  getHz: function(/**Boolean*/ normalize) {
		var p = this.period;

		// Adjust period based on the calibration test time
		if (normalize && !this.isCalibration) {
		  var cal = Test.CALIBRATIONS[this.loopArg ? 0 : 1];

		  // If the period is within 20% of the calibration time, then zero the
		  // it out
		  p = p < cal.period*1.2 ? 0 : p - cal.period;
		}

		return Math.round(1/p);
	  },

	  /**
	  * Get a friendly string describing the test
	  */
	  toString: function() {
		return this.name + ' - '  + this.time/this.count + ' secs';
	  }
	});

	// CSS we need for the UI
	var STYLESHEET = '<style> \
	  body {margin:1vw;} \
	  #jslitmus {font-family:sans-serif; font-size: 12px;} \
	  #jslitmus a {text-decoration: none;} \
	  #jslitmus a:hover {text-decoration: underline;} \
	  #jsl_status { \
		margin-top: 10px; \
		font-size: 10px; \
		color: #888; \
	  } \
	  A IMG  {border:none} \
	  #test_results { \
		margin-top: 10px; \
		width: 100%; \
		font-size: 12px; \
		font-family: sans-serif; \
		border-collapse: collapse; \
		border-spacing: 0px; \
	  } \
	  #test_results th, #test_results td { \
		border: solid 1px #ccc; \
		vertical-align: top; \
		padding: 3px; \
	  } \
	  #test_results th { \
		vertical-align: bottom; \
		background-color: #ccc; \
		padding: 1px; \
		font-size: 10px; \
	  } \
	  #test_results #test_platform { \
		color: #444; \
		text-align:center; \
	  } \
	  #test_results .test_row { \
		color: #006; \
		cursor: pointer; \
	  } \
	  #test_results .test_nonlooping { \
		border-left-style: dotted; \
		border-left-width: 2px; \
		text-align: center; \
		width: 300px; \
	  } \
	  #test_results .test_looping { \
		border-left-style: solid; \
		border-left-width: 2px; \
		text-align: center; \
		width: 300px; \
	  } \
	  #test_results .test_name {white-space: nowrap;} \
	  #test_results .test_pending { \
	  } \
	  #test_results .test_running { \
		font-style: italic; \
	  } \
	  #test_results .test_done {} \
	  #test_results .test_done { \
		text-align: center; \
		width: 300px; \
		font-family: monospace; \
	  } \
	  #test_results .test_error {color: #600;} \
	  #test_results .test_error .error_head {font-weight:bold;} \
	  #test_results .test_error .error_body {font-size:85%;} \
	  #test_results .test_row:hover td { \
		background-color: #ffc; \
		text-decoration: underline; \
	  } \
	  #chart { \
		margin: 10px 0px; \
		width: 250px; \
	  } \
	  #chart img { \
		border: solid 1px #ccc; \
		margin-bottom: 5px; \
		width: 98vw; \
		display: inline-block; \
		min-height: 100px; \
	  } \
	  #chart #tiny_url { \
		min-height: 100px; \
		width: 98vw; \
	  } \
	  #jslitmus_credit { \
		font-size: 10px; \
		color: #888; \
		margin-top: 8px; \
	  } \
	  </style>';

	// HTML markup for the UI
	var MARKUP = '<div id="jslitmus"> \
		<button onclick="JSLitmus.runAll(event)">启动测试</button> \
		<button id="stop_button" disabled="disabled" onclick="JSLitmus.stop()">停止测试</button> \
		<br \> \
		<br \> \
		<input type="checkbox" style="vertical-align: middle" id="test_normalize" checked="checked" onchange="JSLitmus.renderAll()""> 是否启用规范化结果？ \
		<table id="test_results"> \
		  <colgroup> \
			<col /> \
			<col width="100" /> \
		  </colgroup> \
		  <tr><th id="test_platform" colspan="2">' + platform + '</th></tr> \
		  <tr><th>测试用例名称</th><th>每秒执行次数</th></tr> \
		  <tr id="test_row_template" class="test_row" style="display:none"> \
			<td class="test_name"></td> \
			<td class="test_result">Ready</td> \
		  </tr> \
		</table> \
		<div id="jsl_status"></div> \
		<div id="chart" style="display:none"> \
		<a id="chart_link" target="_blank"><img id="chart_image"></a> \
		TinyURL (for chart): \
		<iframe id="tiny_url" frameBorder="0" scrolling="no" src=""></iframe> \
		</div> \
		<a id="jslitmus_credit" title="JSLitmus home page" href="http://code.google.com/p/jslitmus" target="_blank">Powered by JSLitmus</a> \
	  </div>';

	/**
	 * The public API for creating and running tests
	 */
	window.JSLitmus = {
	  /** The list of all tests that have been registered with JSLitmus.test */
	  _tests: [],
	  /** The queue of tests that need to be run */
	  _queue: [],

	  /**
	  * The parsed query parameters the current page URL.  This is provided as a
	  * convenience for test functions - it's not used by JSLitmus proper
	  */
	  params: {},

	  /**
	   * Initialize
	   */
	  _init: function() {
		// Parse query params into JSLitmus.params[] hash
		var match = (location + '').match(/([^?#]*)(#.*)?$/);
		if (match) {
		  var pairs = match[1].split('&');
		  for (var i = 0; i < pairs.length; i++) {
			var pair = pairs[i].split('=');
			if (pair.length > 1) {
			  var key = pair.shift();
			  var value = pair.length > 1 ? pair.join('=') : pair[0];
			  this.params[key] = value;
			}
		  }
		}

		// Write out the stylesheet.  We have to do this here because IE
		// doesn't honor sheets written after the document has loaded.
		document.write(STYLESHEET);

		// Setup the rest of the UI once the document is loaded
		if (window.addEventListener) {
		  window.addEventListener('load', this._setup, false);
		} else if (document.addEventListener) {
		  document.addEventListener('load', this._setup, false);
		} else if (window.attachEvent) {
		  window.attachEvent('onload', this._setup);
		}

		return this;
	  },

	  /**
	   * Set up the UI
	   */
	  _setup: function() {
		var el = jsl.$('jslitmus_container');
		if (!el) document.body.appendChild(el = document.createElement('div'));

		el.innerHTML = MARKUP;

		// Render the UI for all our tests
		for (var i=0; i < JSLitmus._tests.length; i++)
		  JSLitmus.renderTest(JSLitmus._tests[i]);
	  },

	  /**
	   * (Re)render all the test results
	   */
	  renderAll: function() {
		for (var i = 0; i < JSLitmus._tests.length; i++)
		  JSLitmus.renderTest(JSLitmus._tests[i]);
		JSLitmus.renderChart();
	  },

	  /**
	   * (Re)render the chart graphics
	   */
	  renderChart: function() {
		var url = JSLitmus.chartUrl();
		jsl.$('chart_link').href = url;
		jsl.$('chart_image').src = url;
		jsl.$('chart').style.display = '';

		// Update the tiny URL
		jsl.$('tiny_url').src = 'http://tinyurl.com/api-create.php?url='+escape(url);
	  },

	  /**
	   * (Re)render the results for a specific test
	   */
	  renderTest: function(test) {
		// Make a new row if needed
		if (!test._row) {
		  var trow = jsl.$('test_row_template');
		  if (!trow) return;

		  test._row = trow.cloneNode(true);
		  test._row.style.display = '';
		  test._row.id = '';
		  test._row.onclick = function() {JSLitmus._queueTest(test);};
		  test._row.title = 'Run ' + test.name + ' test';
		  trow.parentNode.appendChild(test._row);
		  test._row.cells[0].innerHTML = test.name;
		}

		var cell = test._row.cells[1];
		var cns = [test.loopArg ? 'test_looping' : 'test_nonlooping'];

		if (test.error) {
		  cns.push('test_error');
		  cell.innerHTML =
		  '<div class="error_head">' + test.error + '</div>' +
		  '<ul class="error_body"><li>' +
			jsl.join(test.error, ': ', '</li><li>') +
			'</li></ul>';
		} else {
		  if (test.running) {
			cns.push('test_running');
			cell.innerHTML = '运行中';
		  } else if (jsl.indexOf(JSLitmus._queue, test) >= 0) {
			cns.push('test_pending');
			cell.innerHTML = '准备中';
		  } else if (test.count) {
			cns.push('test_done');
			var hz = test.getHz(jsl.$('test_normalize').checked);
			cell.innerHTML = hz != Infinity ? hz : '&infin;';
			cell.title = 'Looped ' + test.count + ' times in ' + test.time + ' seconds';
		  } else {
			cell.innerHTML = '准备完毕';
		  }
		}
		cell.className = cns.join(' ');
	  },

	  /**
	   * Create a new test
	   */
	  test: function(name, f) {
		// Create the Test object
		var test = new Test(name, f);
		JSLitmus._tests.push(test);

		// Re-render if the test state changes
		test.onChange = JSLitmus.renderTest;

		// Run the next test if this one finished
		test.onStop = function(test) {
		  if (JSLitmus.onTestFinish) JSLitmus.onTestFinish(test);
		  JSLitmus.currentTest = null;
		  JSLitmus._nextTest();
		};

		// Render the new test
		this.renderTest(test);
	  },

	  /**
	   * Add all tests to the run queue
	   */
	  runAll: function(e) {
		e = e || window.event;
		var reverse = e && e.shiftKey, len = JSLitmus._tests.length;
		for (var i = 0; i < len; i++) {
		  JSLitmus._queueTest(JSLitmus._tests[!reverse ? i : (len - i - 1)]);
		}
	  },

	  /**
	   * Remove all tests from the run queue.  The current test has to finish on
	   * it's own though
	   */
	  stop: function() {
		while (JSLitmus._queue.length) {
		  var test = JSLitmus._queue.shift();
		  JSLitmus.renderTest(test);
		}
	  },

	  /**
	   * Run the next test in the run queue
	   */
	  _nextTest: function() {
		if (!JSLitmus.currentTest) {
		  var test = JSLitmus._queue.shift();
		  if (test) {
			jsl.$('stop_button').disabled = false;
			JSLitmus.currentTest = test;
			test.run();
			JSLitmus.renderTest(test);
			if (JSLitmus.onTestStart) JSLitmus.onTestStart(test);
		  } else {
			jsl.$('stop_button').disabled = true;
			JSLitmus.renderChart();
		  }
		}
	  },

	  /**
	   * Add a test to the run queue
	   */
	  _queueTest: function(test) {
		if (jsl.indexOf(JSLitmus._queue, test) >= 0) return;
		JSLitmus._queue.push(test);
		JSLitmus.renderTest(test);
		JSLitmus._nextTest();
	  },

	  /**
	   * Generate a Google Chart URL that shows the data for all tests
	   */
	  chartUrl: function() {
		var n = JSLitmus._tests.length, markers = [], data = [];
		var d, min = 0, max = -1e10;
		var normalize = jsl.$('test_normalize').checked;

		// Gather test data
		for (var i=0; i < JSLitmus._tests.length; i++) {
		  var test = JSLitmus._tests[i];
		  if (test.count) {
			var hz = test.getHz(normalize);
			var v = hz != Infinity ? hz : 0;
			data.push(v);
			markers.push('t' + jsl.escape(test.name + '(' + jsl.toLabel(hz)+ ')') + ',000000,0,' +
			  markers.length + ',10');
			max = Math.max(v, max);
		  }
		}
		if (markers.length <= 0) return null;

		// Build chart title
		var title = document.getElementsByTagName('title');
		title = (title && title.length) ? title[0].innerHTML : null;
		var chart_title = [];
		if (title) chart_title.push(title);
		chart_title.push('Ops/sec (' + platform + ')');

		// Build labels
		var labels = [jsl.toLabel(min), jsl.toLabel(max)];

		var w = 250, bw = 15;
		var bs = 5;
		var h = markers.length*(bw + bs) + 30 + chart_title.length*20;

		var params = {
		  chtt: escape(chart_title.join('|')),
		  chts: '000000,10',
		  cht: 'bhg',                     // chart type
		  chd: 't:' + data.join(','),     // data set
		  chds: min + ',' + max,          // max/min of data
		  chxt: 'x',                      // label axes
		  chxl: '0:|' + labels.join('|'), // labels
		  chsp: '0,1',
		  chm: markers.join('|'),         // test names
		  chbh: [bw, 0, bs].join(','),    // bar widths
		  // chf: 'bg,lg,0,eeeeee,0,eeeeee,.5,ffffff,1', // gradient
		  chs: w + 'x' + h
		};
		return 'http://chart.apis.google.com/chart?' + jsl.join(params, '=', '&');
	  }
	};

	JSLitmus._init();
  })();
